<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <title>惊蛰的博客 实现一个大文件上传和断点续传</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <style>
        @import url('/css/post-card.css');
        @import url('/css/main.css');
        @import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,400;0,600;1,400;1,600&family=Roboto+Slab:wght@700&display=swap');
    </style>
    <style>
        body {
            font-family: Montserrat, sans;
        }

        .page-footer {
            color: var(--text-p3);
        }

        .sitemap {
            display: flex;
            justify-content: space-between;
            margin: .5rem 0 2rem;
            display: grid;
            grid-gap: 1rem 1rem;
            grid-auto-flow: column dense;
        }

        .sitemap-group {
            display: flex;
            flex-direction: column;
            align-items: flex-start;
        }

        .sitemap-group>.fs14 {
            margin: .5rem 0;
        }

        .page-footer .sitemap .sitemap-group>a {
            margin: .2rem 0;
            color: var(--text-p3);
        }

        .page-footer .text p {
            margin: 4px 0;
            line-height: 1.2;
        }
    </style>
    <script src="https://lib.baomitu.com/jquery/3.6.0/jquery.min.js"></script>
    <script src="/js/index.js"></script>
    <link href="https://cdn.bootcdn.net/ajax/libs/jquery.scrollbar/0.2.9/jquery.scrollbar.min.css" rel="stylesheet">
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery_lazyload/1.9.7/jquery.lazyload.min.js"></script>
<meta name="generator" content="Hexo 5.4.0"></head>

<body>
    
<div class="l_body">
    <div class="l_main list">
        
<div class="nav-wrap mobile-hidden">
    <nav class="sub post cap header1">
        <a  href="/">最近发布</a>
        <a 
            href="/wiki/">分类</a>
        <a 
            href="/notes/">标签</a>
        <a href="/bibi/">说说</a>

    </nav>
    <div class="post-body-title header2">
        实现一个大文件上传和断点续传
    </div>
</div>

<style>
    .sub2 {
        display: flex;
        flex-direction: column;
        position: absolute;
        z-index: 11;
        background:#ffff!important;
        border-radius: 5px;
    }

    .sub2 a{
        display: block;
        margin-left: 0px!important;
        border-radius: 5px;
        margin: 5px 0 3px 0!important;
    }
</style>


<script>
    $(function () {
        //页面初始化的时候，获取滚动条的高度（上次高度）
        var start_height = $(document).scrollTop();
        //获取导航栏的高度(包含 padding 和 border)
        var navigation_height = $('.header1').outerHeight();
        console.log(start_height, navigation_height);
        $(window).scroll(function () {
            //触发滚动事件后，滚动条的高度（本次高度）
            var end_height = $(document).scrollTop();
            console.log(end_height);
            //触发后的高度 与 元素的高度对比
            if (end_height > navigation_height) {
                $('.header1').addClass('hide');
                $('.header2').addClass('hide');
            } else {
                $('.header1').removeClass('hide');
                $('.header2').removeClass('hide');
            }
            //触发后的高度 与 上次触发后的高度 比较
            if (end_height < start_height) {
                $('.header1').removeClass('hide');
                $('.header2').removeClass('hide');
            }
            //再次获取滚动条的高度，用于下次触发事件后的对比
            start_height = $(document).scrollTop();
        });
    });
</script>


        <h2>
            实现一个大文件上传和断点续传
        </h2>

        <div class="body-main-content">
            <h4 id="本文将从零搭建前端和服务端，实现一个大文件上传和断点续传的-demo"><a href="#本文将从零搭建前端和服务端，实现一个大文件上传和断点续传的-demo" class="headerlink" title="本文将从零搭建前端和服务端，实现一个大文件上传和断点续传的 demo"></a>本文将从零搭建前端和服务端，实现一个大文件上传和断点续传的 demo</h4><h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>本文将从零搭建前端和服务端，实现一个大文件上传和断点续传的 demo</p>
<p>前端：vue element-ui</p>
<p>服务端：nodejs</p>
<p>文章有误解的地方，欢迎指出，将在第一时间改正，有更好的实现方式希望留下你的评论</p>
<p>大文件上传</p>
<h2 id="整体思路"><a href="#整体思路" class="headerlink" title="整体思路"></a>整体思路</h2><h3 id="前端"><a href="#前端" class="headerlink" title="前端"></a>前端</h3><p>前端大文件上传网上的大部分文章已经给出了解决方案，核心是利用 Blob.prototype.slice 方法，和数组的 slice 方法相似，调用的 slice 方法可以返回原文件的某个切片</p>
<p>这样我们就可以根据预先设置好的切片最大数量将文件切分为一个个切片，然后借助 http 的可并发性，同时上传多个切片，这样从原本传一个大文件，变成了同时传多个小的文件切片，可以大大减少上传时间</p>
<p>另外由于是并发，传输到服务端的顺序可能会发生变化，所以我们还需要给每个切片记录顺序</p>
<h3 id="服务端"><a href="#服务端" class="headerlink" title="服务端"></a>服务端</h3><p>服务端需要负责接受这些切片，并在接收到所有切片后合并切片</p>
<p>这里又引伸出两个问题</p>
<h4 id="何时合并切片，即切片什么时候传输完成？如何合并切片？"><a href="#何时合并切片，即切片什么时候传输完成？如何合并切片？" class="headerlink" title="何时合并切片，即切片什么时候传输完成？如何合并切片？"></a>何时合并切片，即切片什么时候传输完成？如何合并切片？</h4><p>第一个问题需要前端进行配合，前端在每个切片中都携带切片最大数量的信息，当服务端接受到这个数量的切片时自动合并，也可以额外发一个请求主动通知服务端进行切片的合并</p>
<p>第二个问题，具体如何合并切片呢？这里可以使用 nodejs 的 api fs.appendFileSync，它可以同步地将数据追加到指定文件，也就是说，当服务端接受到所有切片后，先创建一个最终的文件，然后将所有切片逐步合并到这个文件中</p>
<p>talk is cheap,show me the code，接着我们用代码实现上面的思路</p>
<h3 id="前端部分"><a href="#前端部分" class="headerlink" title="前端部分"></a>前端部分</h3><p>前端使用 Vue 作为开发框架，对界面没有太大要求，原生也可以，考虑到美观使用 element-ui 作为 UI 框架</p>
<h4 id="上传控件"><a href="#上传控件" class="headerlink" title="上传控件"></a>上传控件</h4><p>首先创建选择文件的控件，监听 change 事件以及上传按钮</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line">&lt;template&gt;</span><br><span class="line">   &lt;div&gt;</span><br><span class="line">    &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;</span><br><span class="line">    &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;</span><br><span class="line">  &lt;/div&gt;</span><br><span class="line">&lt;/template&gt;</span><br><span class="line"></span><br><span class="line">&lt;script&gt;</span><br><span class="line">export default &#123;</span><br><span class="line">  data: () =&gt; (&#123;</span><br><span class="line">    container: &#123;</span><br><span class="line">      file: null</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;),</span><br><span class="line">  methods: &#123;</span><br><span class="line">    async handleFileChange(e) &#123;</span><br><span class="line">      const [file] = e.target.files;</span><br><span class="line">      if (!file) return;</span><br><span class="line">      Object.assign(this.$data, this.$options.data());</span><br><span class="line">      this.container.file = file;</span><br><span class="line">    &#125;,</span><br><span class="line">    async handleUpload() &#123;&#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;;</span><br><span class="line">&lt;/script&gt;\</span><br></pre></td></tr></table></figure>

<h4 id="请求逻辑"><a href="#请求逻辑" class="headerlink" title="请求逻辑"></a>请求逻辑</h4><p>考虑到通用性，这里没有用第三方的请求库，而是用原生 XMLHttpRequest 做一层简单的封装来发请求</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">request(&#123;</span><br><span class="line">      url,</span><br><span class="line">      method = &quot;post&quot;,</span><br><span class="line">      data,</span><br><span class="line">      headers = &#123;&#125;,</span><br><span class="line">      requestList</span><br><span class="line">    &#125;) &#123;</span><br><span class="line">      return new Promise(resolve =&gt; &#123;</span><br><span class="line">        const xhr = new XMLHttpRequest();</span><br><span class="line">        xhr.open(method, url);</span><br><span class="line">        Object.keys(headers).forEach(key =&gt;</span><br><span class="line">          xhr.setRequestHeader(key, headers[key])</span><br><span class="line">        );</span><br><span class="line">        xhr.send(data);</span><br><span class="line">        xhr.onload = e =&gt; &#123;</span><br><span class="line">          resolve(&#123;</span><br><span class="line">            data: e.target.response</span><br><span class="line">          &#125;);</span><br><span class="line">        &#125;;</span><br><span class="line">      &#125;);</span><br><span class="line">    &#125;</span><br></pre></td></tr></table></figure>
<h4 id="上传切片"><a href="#上传切片" class="headerlink" title="上传切片"></a>上传切片</h4><p>接着实现比较重要的上传功能，上传需要做两件事</p>
<h4 id="对文件进行切片"><a href="#对文件进行切片" class="headerlink" title="对文件进行切片"></a>对文件进行切片</h4><p>将切片传输给服务端</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br></pre></td><td class="code"><pre><span class="line">  &lt;div&gt;</span><br><span class="line">    &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;</span><br><span class="line">    &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;</span><br><span class="line">  &lt;/div&gt;</span><br><span class="line">&lt;/template&gt;</span><br><span class="line"></span><br><span class="line">&lt;script&gt;</span><br><span class="line">const LENGTH = 10; // 切片数量</span><br><span class="line"></span><br><span class="line">export default &#123;</span><br><span class="line">  data: () =&gt; (&#123;</span><br><span class="line">    container: &#123;</span><br><span class="line">      file: null,</span><br><span class="line">    data: []</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;),</span><br><span class="line">  methods: &#123;</span><br><span class="line">    request() &#123;&#125;,</span><br><span class="line">    async handleFileChange() &#123;&#125;,</span><br><span class="line">   // 生成文件切片</span><br><span class="line">   createFileChunk(file, length = LENGTH) &#123;</span><br><span class="line">     const fileChunkList = [];</span><br><span class="line">     const chunkSize = Math.ceil(file.size / length);</span><br><span class="line">     let cur = 0;</span><br><span class="line">     while (cur &lt; file.size) &#123;</span><br><span class="line">       fileChunkList.push(&#123; file: file.slice(cur, cur chunkSize) &#125;);</span><br><span class="line">       cur += chunkSize;</span><br><span class="line">     &#125;</span><br><span class="line">     return fileChunkList;</span><br><span class="line">   &#125;,</span><br><span class="line">  // 上传切片</span><br><span class="line">   async uploadChunks() &#123;</span><br><span class="line">     const requestList = this.data</span><br><span class="line">       .map((&#123; chunk &#125;) =&gt; &#123;</span><br><span class="line">         const formData = new FormData();</span><br><span class="line">         formData.append(&quot;chunk&quot;, chunk);</span><br><span class="line">+					 formData.append(&quot;hash&quot;, hash);</span><br><span class="line">         formData.append(&quot;filename&quot;, this.container.file.name);</span><br><span class="line">         return &#123; formData &#125;;</span><br><span class="line">       &#125;)</span><br><span class="line">       .map(async (&#123; formData &#125;) =&gt;</span><br><span class="line">         this.request(&#123;</span><br><span class="line">           url: &quot;http://localhost:3000&quot;,</span><br><span class="line">           data: formData</span><br><span class="line">         &#125;)</span><br><span class="line">       );</span><br><span class="line">     await Promise.all(requestList); // 并发切片</span><br><span class="line">   &#125;,</span><br><span class="line">   async handleUpload() &#123;</span><br><span class="line">     if (!this.container.file) return;</span><br><span class="line">     const fileChunkList = this.createFileChunk(this.container.file);</span><br><span class="line">     this.data = fileChunkList.map((&#123; file &#125;，index) =&gt; (&#123;</span><br><span class="line">       chunk: file,</span><br><span class="line">       hash: this.container.file.name &quot;-&quot; index // 文件名 数组下标</span><br><span class="line">     &#125;));</span><br><span class="line">     await this.uploadChunks();</span><br><span class="line">   &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;;</span><br><span class="line">&lt;/script&gt;</span><br></pre></td></tr></table></figure>
<p>当点击上传按钮时，调用 createFileChunk 将文件切片，切片数量通过一个常量 Length 控制，这里设置为 10，即将文件分成 10 个切片上传</p>
<p>createFileChunk 内使用 while 循环和 slice 方法将切片放入 fileChunkList 数组中返回</p>
<p>在生成文件切片时，需要给每个切片一个标识作为 hash，这里暂时使用文件名 下标，这样后端可以知道当前切片是第几个切片，由于之后的合并切片</p>
<p>随后调用 uploadChunks 上传所有的文件切片，将文件切片，切片 hash，以及文件名放入 FormData 中，再调用上一步的 request 函数返回一个 proimise，最后调用 Promise.all 并发上传所有的切片</p>
<h4 id="发送合并请求"><a href="#发送合并请求" class="headerlink" title="发送合并请求"></a>发送合并请求</h4><p>这里使用整体思路中提到的第二种合并切片的方式，即前端主动通知服务端进行合并，所以前端还需要额外发请求，服务端接受到这个请求时主动合并切片</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br></pre></td><td class="code"><pre><span class="line">  &lt;div&gt;</span><br><span class="line">    &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;</span><br><span class="line">    &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;</span><br><span class="line">  &lt;/div&gt;</span><br><span class="line">&lt;/template&gt;</span><br><span class="line"></span><br><span class="line">&lt;script&gt;</span><br><span class="line">export default &#123;</span><br><span class="line">  data: () =&gt; (&#123;</span><br><span class="line">    container: &#123;</span><br><span class="line">      file: null</span><br><span class="line">    &#125;,</span><br><span class="line">    data: []</span><br><span class="line">  &#125;),</span><br><span class="line">  methods: &#123;</span><br><span class="line">    request() &#123;&#125;,</span><br><span class="line">    async handleFileChange() &#123;&#125;,</span><br><span class="line">    createFileChunk() &#123;&#125;,</span><br><span class="line">    // 上传切片，同时过滤已上传的切片</span><br><span class="line">    async uploadChunks() &#123;</span><br><span class="line">      const requestList = this.data</span><br><span class="line">        .map((&#123; chunk &#125;) =&gt; &#123;</span><br><span class="line">          const formData = new FormData();</span><br><span class="line">          formData.append(&quot;chunk&quot;, chunk);</span><br><span class="line">          formData.append(&quot;hash&quot;, hash);</span><br><span class="line">          formData.append(&quot;filename&quot;, this.container.file.name);</span><br><span class="line">          return &#123; formData &#125;;</span><br><span class="line">        &#125;)</span><br><span class="line">        .map(async (&#123; formData &#125;) =&gt;</span><br><span class="line">          this.request(&#123;</span><br><span class="line">            url: &quot;http://localhost:3000&quot;,</span><br><span class="line">            data: formData</span><br><span class="line">          &#125;)</span><br><span class="line">        );</span><br><span class="line">      await Promise.all(requestList);</span><br><span class="line">     // 合并切片</span><br><span class="line">    await this.mergeRequest();</span><br><span class="line">    &#125;,</span><br><span class="line">   async mergeRequest() &#123;</span><br><span class="line">     await this.request(&#123;</span><br><span class="line">       url: &quot;http://localhost:3000/merge&quot;,</span><br><span class="line">       headers: &#123;</span><br><span class="line">         &quot;content-type&quot;: &quot;application/json&quot;</span><br><span class="line">       &#125;,</span><br><span class="line">       data: JSON.stringify(&#123;</span><br><span class="line">         filename: this.container.file.name</span><br><span class="line">       &#125;)</span><br><span class="line">     &#125;);</span><br><span class="line">   &#125;,    </span><br><span class="line">    async handleUpload() &#123;&#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;;</span><br><span class="line">&lt;/script&gt;```</span><br><span class="line">### 服务端部分</span><br><span class="line">简单使用 http 模块搭建服务端</span><br><span class="line"></span><br><span class="line">```const http = require(&quot;http&quot;);</span><br><span class="line">const server = http.createServer();</span><br><span class="line"></span><br><span class="line">server.on(&quot;request&quot;, async (req, res) =&gt; &#123;</span><br><span class="line">  res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);</span><br><span class="line">  res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;);</span><br><span class="line">  if (req.method === &quot;OPTIONS&quot;) &#123;</span><br><span class="line">    res.status = 200;</span><br><span class="line">    res.end();</span><br><span class="line">    return;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line">server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));</span><br></pre></td></tr></table></figure>

<h4 id="接受切片"><a href="#接受切片" class="headerlink" title="接受切片"></a>接受切片</h4><p>使用 multiparty 包处理前端传来的 FormData</p>
<p>在 multiparty.parse 的回调中，files 参数保存了 FormData 中文件，fields 参数保存了 FormData 中非文件的字段</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br></pre></td><td class="code"><pre><span class="line">const http = require(&quot;http&quot;);</span><br><span class="line">const path = require(&quot;path&quot;);</span><br><span class="line">const fse = require(&quot;fs-extra&quot;);</span><br><span class="line">const multiparty = require(&quot;multiparty&quot;);</span><br><span class="line"></span><br><span class="line">const server = http.createServer();</span><br><span class="line">const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录</span><br><span class="line"></span><br><span class="line">server.on(&quot;request&quot;, async (req, res) =&gt; &#123;</span><br><span class="line">  res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);</span><br><span class="line">  res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;);</span><br><span class="line">  if (req.method === &quot;OPTIONS&quot;) &#123;</span><br><span class="line">    res.status = 200;</span><br><span class="line">    res.end();</span><br><span class="line">    return;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line"> const multipart = new multiparty.Form();</span><br><span class="line"></span><br><span class="line"> multipart.parse(req, async (err, fields, files) =&gt; &#123;</span><br><span class="line">   if (err) &#123;</span><br><span class="line">     return;</span><br><span class="line">   &#125;</span><br><span class="line">   const [chunk] = files.chunk;</span><br><span class="line">   const [hash] = fields.hash;</span><br><span class="line">   const [filename] = fields.filename;</span><br><span class="line">   const chunkDir = `$&#123;UPLOAD_DIR&#125;/$&#123;filename&#125;`;</span><br><span class="line"></span><br><span class="line">  // 切片目录不存在，创建切片目录</span><br><span class="line">   if (!fse.existsSync(chunkDir)) &#123;</span><br><span class="line">     await fse.mkdirs(chunkDir);</span><br><span class="line">   &#125;</span><br><span class="line"></span><br><span class="line">   // 重命名文件</span><br><span class="line">   await fse.rename(chunk.path, `$&#123;chunkDir&#125;/$&#123;hash&#125;`);</span><br><span class="line">   res.end(&quot;received file chunk&quot;);</span><br><span class="line"> &#125;);</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line">server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));</span><br></pre></td></tr></table></figure>
<p>查看 multiparty 处理后的 chunk 对象，path 是存储临时文件的路径，size 是临时文件大小，在 multiparty 文档中提到可以使用 fs.rename 重命名的方式移动临时文件，也就是文件切片</p>
<p>在接受文件切片时，需要先创建存储切片的文件夹，由于前端在发送每个切片时额外携带了唯一值 hash，所以以 hash 作为文件名，将切片从临时路径移动切片文件夹中，最后的结果如下</p>
<h4 id="合并切片"><a href="#合并切片" class="headerlink" title="合并切片"></a>合并切片</h4><p>在接收到前端发送的合并请求后，服务端将文件夹下的所有切片进行合并</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br></pre></td><td class="code"><pre><span class="line">const http = require(&quot;http&quot;);</span><br><span class="line">const path = require(&quot;path&quot;);</span><br><span class="line">const fse = require(&quot;fs-extra&quot;);</span><br><span class="line"></span><br><span class="line">const server = http.createServer();</span><br><span class="line">const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录</span><br><span class="line"></span><br><span class="line">const resolvePost = req =&gt;</span><br><span class="line">  new Promise(resolve =&gt; &#123;</span><br><span class="line">    let chunk = &quot;&quot;;</span><br><span class="line">    req.on(&quot;data&quot;, data =&gt; &#123;</span><br><span class="line">      chunk += data;</span><br><span class="line">    &#125;);</span><br><span class="line">    req.on(&quot;end&quot;, () =&gt; &#123;</span><br><span class="line">      resolve(JSON.parse(chunk));</span><br><span class="line">    &#125;);</span><br><span class="line">  &#125;);</span><br><span class="line"></span><br><span class="line">// 合并切片</span><br><span class="line">const mergeFileChunk = async (filePath, filename) =&gt; &#123;</span><br><span class="line">  const chunkDir = `$&#123;UPLOAD_DIR&#125;/$&#123;filename&#125;`;</span><br><span class="line">  const chunkPaths = await fse.readdir(chunkDir);</span><br><span class="line">  await fse.writeFile(filePath, &quot;&quot;);</span><br><span class="line">  chunkPaths.forEach(chunkPath =&gt; &#123;</span><br><span class="line">    fse.appendFileSync(filePath, fse.readFileSync(`$&#123;chunkDir&#125;/$&#123;chunkPath&#125;`));</span><br><span class="line">    fse.unlinkSync(`$&#123;chunkDir&#125;/$&#123;chunkPath&#125;`);</span><br><span class="line">  &#125;);</span><br><span class="line">  fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录</span><br><span class="line">&#125;;</span><br><span class="line"></span><br><span class="line">server.on(&quot;request&quot;, async (req, res) =&gt; &#123;</span><br><span class="line">  res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);</span><br><span class="line">  res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;);</span><br><span class="line">  if (req.method === &quot;OPTIONS&quot;) &#123;</span><br><span class="line">    res.status = 200;</span><br><span class="line">    res.end();</span><br><span class="line">    return;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  if (req.url === &quot;/merge&quot;) &#123;</span><br><span class="line">    const data = await resolvePost(req);</span><br><span class="line">    const &#123; filename &#125; = data;</span><br><span class="line">    const filePath = `$&#123;UPLOAD_DIR&#125;/$&#123;filename&#125;`;</span><br><span class="line">    await mergeFileChunk(filePath, filename);</span><br><span class="line">    res.end(</span><br><span class="line">      JSON.stringify(&#123;</span><br><span class="line">        code: 0,</span><br><span class="line">        message: &quot;file merged success&quot;</span><br><span class="line">      &#125;)</span><br><span class="line">    );</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line">server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));</span><br></pre></td></tr></table></figure>
<p>由于前端在发送合并请求时会携带文件名，服务端根据文件名可以找到上一步创建的切片文件夹</p>
<p>接着使用 fs.writeFileSync 先创建一个空文件，这个空文件的文件名就是切片文件夹名 后缀名组合而成，随后通过 fs.appendFileSync 从切片文件夹中不断将切片合并到空文件中，每次合并完成后删除这个切片，等所有切片都合并完毕后最后删除切片文件夹</p>
<p>至此一个简单的大文件上传就完成了，接下来我们再此基础上扩展一些额外的功能</p>
<p>显示上传进度条<br>上传进度分两种，一个是每个切片的上传进度，另一个是整个文件的上传进度，而整个文件的上传进度是基于每个切片上传进度计算而来，所以我们先实现切片的上传进度</p>
<h4 id="切片进度条"><a href="#切片进度条" class="headerlink" title="切片进度条"></a>切片进度条</h4><p>XMLHttpRequest 原生支持上传进度的监听，只需要监听 upload.onprogress 即可，我们在原来的 request 基础上传入 onProgress 参数，给 XMLHttpRequest 注册监听事件</p>
 <figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line">// xhr</span><br><span class="line">    request(&#123;</span><br><span class="line">      url,</span><br><span class="line">      method = &quot;post&quot;,</span><br><span class="line">      data,</span><br><span class="line">      headers = &#123;&#125;,</span><br><span class="line">     onProgress = e =&gt; e,</span><br><span class="line">      requestList</span><br><span class="line">    &#125;) &#123;</span><br><span class="line">      return new Promise(resolve =&gt; &#123;</span><br><span class="line">        const xhr = new XMLHttpRequest();</span><br><span class="line">       xhr.upload.onprogress = onProgress;</span><br><span class="line">        xhr.open(method, url);</span><br><span class="line">        Object.keys(headers).forEach(key =&gt;</span><br><span class="line">          xhr.setRequestHeader(key, headers[key])</span><br><span class="line">        );</span><br><span class="line">        xhr.send(data);</span><br><span class="line">        xhr.onload = e =&gt; &#123;</span><br><span class="line">          resolve(&#123;</span><br><span class="line">            data: e.target.response</span><br><span class="line">          &#125;);</span><br><span class="line">        &#125;;</span><br><span class="line">      &#125;);</span><br><span class="line">    &#125;</span><br></pre></td></tr></table></figure>
<p>由于每个切片都需要触发独立的监听事件，所以还需要一个工厂函数，根据传入的切片返回不同的监听函数</p>
<p>在原先的前端上传逻辑中新增监听函数部分</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line">// 上传切片，同时过滤已上传的切片</span><br><span class="line">    async uploadChunks(uploadedList = []) &#123;</span><br><span class="line">      const requestList = this.data</span><br><span class="line">        .map((&#123; chunk &#125;) =&gt; &#123;</span><br><span class="line">          const formData = new FormData();</span><br><span class="line">          formData.append(&quot;chunk&quot;, chunk);</span><br><span class="line">          formData.append(&quot;filename&quot;, this.container.file.name);</span><br><span class="line">          return &#123; formData &#125;;</span><br><span class="line">        &#125;)</span><br><span class="line">        .map(async (&#123; formData &#125;) =&gt;</span><br><span class="line">          this.request(&#123;</span><br><span class="line">            url: &quot;http://localhost:3000&quot;,</span><br><span class="line">            data: formData，</span><br><span class="line">          onProgress: this.createProgressHandler(this.data[index]),</span><br><span class="line">          &#125;)</span><br><span class="line">        );</span><br><span class="line">      await Promise.all(requestList);</span><br><span class="line">       // 合并切片</span><br><span class="line">      await this.mergeRequest();</span><br><span class="line">    &#125;,</span><br><span class="line">    async handleUpload() &#123;</span><br><span class="line">      if (!this.container.file) return;</span><br><span class="line">      const fileChunkList = this.createFileChunk(this.container.file);</span><br><span class="line">      this.data = fileChunkList.map((&#123; file &#125;，index) =&gt; (&#123;</span><br><span class="line">        chunk: file,</span><br><span class="line">      index,</span><br><span class="line">        hash: this.container.file.name &quot;-&quot; index</span><br><span class="line">      percentage:0</span><br><span class="line">      &#125;));</span><br><span class="line">      await this.uploadChunks();</span><br><span class="line">    &#125;    </span><br><span class="line">  createProgressHandler(item) &#123;</span><br><span class="line">     return e =&gt; &#123;</span><br><span class="line">       item.percentage = parseInt(String((e.loaded / e.total) * 100));</span><br><span class="line">     &#125;;</span><br><span class="line">   &#125;</span><br></pre></td></tr></table></figure>
<p>每个切片在上传时都会通过监听函数更新 data 数组对应元素的 percentage 属性，之后把将 data 数组放到视图中展示即可</p>
<p>文件进度条<br>将每个切片已上传的部分累加，除以整个文件的大小，就能得出当前文件的上传进度，所以这里使用 Vue 计算属性</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">computed: &#123;</span><br><span class="line">       uploadPercentage() &#123;</span><br><span class="line">          if (!this.container.file || !this.data.length) return 0;</span><br><span class="line">          const loaded = this.data</span><br><span class="line">            .map(item =&gt; item.size * item.percentage)</span><br><span class="line">            .reduce((acc, cur) =&gt; acc cur);</span><br><span class="line">          return parseInt((loaded / this.container.file.size).toFixed(2));</span><br><span class="line">        &#125;</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure>
<h3 id="最终视图如下"><a href="#最终视图如下" class="headerlink" title="最终视图如下"></a>最终视图如下</h3><h4 id="断点续传"><a href="#断点续传" class="headerlink" title="断点续传"></a>断点续传</h4><p>断点续传的原理在于前端/服务端需要记住已上传的切片，这样下次上传就可以跳过之前已上传的部分，有两种方案实现记忆的功能</p>
<h4 id="前端使用-localStorage-记录已上传的切片-hash"><a href="#前端使用-localStorage-记录已上传的切片-hash" class="headerlink" title="前端使用 localStorage 记录已上传的切片 hash"></a>前端使用 localStorage 记录已上传的切片 hash</h4><p>服务端保存已上传的切片 hash，前端每次上传前向服务端获取已上传的切片<br>第一种是前端的解决方案，第二种是服务端，而前端方案有一个缺陷，如果换了个浏览器就失去了记忆的效果，所以这里选取后者</p>
<h4 id="生成-hash"><a href="#生成-hash" class="headerlink" title="生成 hash"></a>生成 hash</h4><p>无论是前端还是服务端，都必须要生成文件和切片的 hash，之前我们使用文件名 切片下标作为切片 hash，这样做文件名一旦修改就失去了效果，而事实上只要文件内容不变，hash 就不应该变化，所以正确的做法是根据文件内容生成 hash，所以我们修改一下 hash 的生成规则</p>
<p>这里用到另一个库 spark-md5，它可以根据文件内容计算出文件的 hash 值，另外考虑到如果上传一个超大文件，读取文件内容计算 hash 是非常耗费时间的，并且会引起 UI 的阻塞，导致页面假死状态，所以我们使用 web-worker 在 worker 线程计算 hash，这样用户仍可以在主界面正常的交互</p>
<p>由于实例化 web-worker 时，参数是一个 js 文件路径且不能跨域，所以我们单独创建一个 hash.js 文件放在 public 目录下，另外在 worker 中也是不允许访问 dom 的，但它提供了importScripts 函数用于导入外部脚本，通过它导入 spark-md5</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line">// /public/hash.js</span><br><span class="line">self.importScripts(&quot;/spark-md5.min.js&quot;); // 导入脚本</span><br><span class="line"></span><br><span class="line">// 生成文件 hash</span><br><span class="line">self.onmessage = e =&gt; &#123;</span><br><span class="line">  const &#123; fileChunkList &#125; = e.data;</span><br><span class="line">  const spark = new self.SparkMD5.ArrayBuffer();</span><br><span class="line">  let percentage = 0;</span><br><span class="line">  let count = 0;</span><br><span class="line">  const loadNext = index =&gt; &#123;</span><br><span class="line">    const reader = new FileReader();</span><br><span class="line">    reader.readAsArrayBuffer(fileChunkList[index].file);</span><br><span class="line">    reader.onload = e =&gt; &#123;</span><br><span class="line">      count++;</span><br><span class="line">      spark.append(e.target.result);</span><br><span class="line">      if (count === fileChunkList.length) &#123;</span><br><span class="line">        self.postMessage(&#123;</span><br><span class="line">          percentage: 100,</span><br><span class="line">          hash: spark.end()</span><br><span class="line">        &#125;);</span><br><span class="line">        self.close();</span><br><span class="line">      &#125; else &#123;</span><br><span class="line">        percentage += 100 / fileChunkList.length;</span><br><span class="line">        self.postMessage(&#123;</span><br><span class="line">          percentage</span><br><span class="line">        &#125;);</span><br><span class="line">        // 递归计算下一个切片</span><br><span class="line">        loadNext(count);</span><br><span class="line">      &#125;</span><br><span class="line">    &#125;;</span><br><span class="line">  &#125;;</span><br><span class="line">  loadNext(0);</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure>
<p>在 worker 线程中，接受文件切片 fileChunkList，利用 FileReader 读取每个切片的 ArrayBuffer 并不断传入 spark-md5 中，每计算完一个切片通过 postMessage 向主线程发送一个进度事件，全部完成后将最终的 hash 发送给主线程</p>
<p>spark-md5 需要根据所有切片才能算出一个 hash 值，不能直接将整个文件放入计算，否则即使不同文件也会有相同的 hash，具体可以看官方文档</p>
<p>spark-md5</p>
<p>接着编写主线程与 worker 线程通讯的逻辑</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line">//生成文件 hash（web-worker）</span><br><span class="line">   calculateHash(fileChunkList) &#123;</span><br><span class="line">     return new Promise(resolve =&gt; &#123;</span><br><span class="line">      // 添加 worker 属性</span><br><span class="line">       this.container.worker = new Worker(&quot;/hash.js&quot;);</span><br><span class="line">       this.container.worker.postMessage(&#123; fileChunkList &#125;);</span><br><span class="line">       this.container.worker.onmessage = e =&gt; &#123;</span><br><span class="line">         const &#123; percentage, hash &#125; = e.data;</span><br><span class="line">         this.hashPercentage = percentage;</span><br><span class="line">         if (hash) &#123;</span><br><span class="line">           resolve(hash);</span><br><span class="line">         &#125;</span><br><span class="line">       &#125;;</span><br><span class="line">     &#125;);</span><br><span class="line">    &#125;,</span><br><span class="line">    async handleUpload() &#123;</span><br><span class="line">      if (!this.container.file) return;</span><br><span class="line">      const fileChunkList = this.createFileChunk(this.container.file);</span><br><span class="line">    this.container.hash = await this.calculateHash(fileChunkList);</span><br><span class="line">      this.data = fileChunkList.map((&#123; file &#125;，index) =&gt; (&#123;</span><br><span class="line">      fileHash: this.container.hash,</span><br><span class="line">        chunk: file,</span><br><span class="line">        hash: this.container.file.name &quot;-&quot; index, // 文件名 数组下标</span><br><span class="line">        percentage:0</span><br><span class="line">      &#125;));</span><br><span class="line">      await this.uploadChunks();</span><br><span class="line">    &#125;  </span><br></pre></td></tr></table></figure>
<p>主线程使用 postMessage 给 worker 线程传入所有切片 fileChunkList，并监听 worker 线程发出的 postMessage 事件拿到文件 hash</p>
<p>加上显示计算 hash 的进度条，看起来像这样</p>
<p>至此前端需要将之前用文件名作为 hash 的地方改写为 workder 返回的这个 hash</p>
<p>服务端则使用 hash 作为切片文件夹名，hash 下标作为切片名，hash 扩展名作为文件名，没有新增的逻辑</p>
<h3 id="文件秒传"><a href="#文件秒传" class="headerlink" title="文件秒传"></a>文件秒传</h3><p>在实现断点续传前先简单介绍一下文件秒传</p>
<p>所谓的文件秒传，即在服务端已经存在了上传的资源，所以当用户再次上传时会直接提示上传成功</p>
<p>文件秒传需要依赖上一步生成的 hash，即在上传前，先计算出文件 hash，并把 hash 发送给服务端进行验证，由于 hash 的唯一性，所以一旦服务端能找到 hash 相同的文件，则直接返回上传成功的信息即可</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><span class="line">async verifyUpload(filename, fileHash) &#123;</span><br><span class="line">     const &#123; data &#125; = await this.request(&#123;</span><br><span class="line">       url: &quot;http://localhost:3000/verify&quot;,</span><br><span class="line">       headers: &#123;</span><br><span class="line">         &quot;content-type&quot;: &quot;application/json&quot;</span><br><span class="line">       &#125;,</span><br><span class="line">       data: JSON.stringify(&#123;</span><br><span class="line">         filename,</span><br><span class="line">         fileHash</span><br><span class="line">       &#125;)</span><br><span class="line">     &#125;);</span><br><span class="line">     return JSON.parse(data);</span><br><span class="line">   &#125;,</span><br><span class="line">  async handleUpload() &#123;</span><br><span class="line">     if (!this.container.file) return;</span><br><span class="line">     const fileChunkList = this.createFileChunk(this.container.file);</span><br><span class="line">     this.container.hash = await this.calculateHash(fileChunkList);</span><br><span class="line">   const &#123; shouldUpload &#125; = await this.verifyUpload(</span><br><span class="line">     this.container.file.name,</span><br><span class="line">     this.container.hash</span><br><span class="line">   );</span><br><span class="line">   if (!shouldUpload) &#123;</span><br><span class="line">     this.$message.success(&quot;秒传：上传成功&quot;);</span><br><span class="line">     return;</span><br><span class="line">  &#125;</span><br><span class="line">    this.data = fileChunkList.map((&#123; file &#125;, index) =&gt; (&#123;</span><br><span class="line">       fileHash: this.container.hash,</span><br><span class="line">       index,</span><br><span class="line">       hash: this.container.hash &quot;-&quot; index,</span><br><span class="line">       chunk: file,</span><br><span class="line">       percentage: 0</span><br><span class="line">     &#125;));</span><br><span class="line">     await this.uploadChunks();</span><br><span class="line">   &#125;   </span><br></pre></td></tr></table></figure>
<p>秒传其实就是给用户看的障眼法，实质上根本没有上传</p>
<p>服务端的逻辑非常简单，新增一个验证接口，验证文件是否存在即可</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br></pre></td><td class="code"><pre><span class="line">const extractExt = filename =&gt;</span><br><span class="line"> filename.slice(filename.lastIndexOf(&quot;.&quot;), filename.length); // 提取后缀名</span><br><span class="line">const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录</span><br><span class="line"></span><br><span class="line">const resolvePost = req =&gt;</span><br><span class="line">  new Promise(resolve =&gt; &#123;</span><br><span class="line">    let chunk = &quot;&quot;;</span><br><span class="line">    req.on(&quot;data&quot;, data =&gt; &#123;</span><br><span class="line">      chunk += data;</span><br><span class="line">    &#125;);</span><br><span class="line">    req.on(&quot;end&quot;, () =&gt; &#123;</span><br><span class="line">      resolve(JSON.parse(chunk));</span><br><span class="line">    &#125;);</span><br><span class="line">  &#125;);</span><br><span class="line"></span><br><span class="line">server.on(&quot;request&quot;, async (req, res) =&gt; &#123;</span><br><span class="line">  if (req.url === &quot;/verify&quot;) &#123;</span><br><span class="line">   const data = await resolvePost(req);</span><br><span class="line">   const &#123; fileHash, filename &#125; = data;</span><br><span class="line">   const ext = extractExt(filename);</span><br><span class="line">   const filePath = `$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;$&#123;ext&#125;`;</span><br><span class="line">   if (fse.existsSync(filePath)) &#123;</span><br><span class="line">     res.end(</span><br><span class="line">       JSON.stringify(&#123;</span><br><span class="line">         shouldUpload: false</span><br><span class="line">       &#125;)</span><br><span class="line">     );</span><br><span class="line">   &#125; else &#123;</span><br><span class="line">     res.end(</span><br><span class="line">       JSON.stringify(&#123;</span><br><span class="line">         shouldUpload: true</span><br><span class="line">       &#125;)</span><br><span class="line">     );</span><br><span class="line">   &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;);</span><br><span class="line">server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));</span><br></pre></td></tr></table></figure>
<p>暂停上传<br>讲完了生成 hash 和文件秒传，回到断点续传</p>
<p>断点续传顾名思义即断点 续传，所以我们第一步先实现“断点”，也就是暂停上传</p>
<p>原理是使用 XMLHttpRequest 的 abort 方法，可以取消一个 xhr 请求的发送，为此我们需要将上传每个切片的 xhr 对象保存起来，我们再改造一下 request 方法</p>
   <figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line">request(&#123;</span><br><span class="line">      url,</span><br><span class="line">      method = &quot;post&quot;,</span><br><span class="line">      data,</span><br><span class="line">      headers = &#123;&#125;,</span><br><span class="line">      onProgress = e =&gt; e,</span><br><span class="line">    requestList</span><br><span class="line">    &#125;) &#123;</span><br><span class="line">      return new Promise(resolve =&gt; &#123;</span><br><span class="line">        const xhr = new XMLHttpRequest();</span><br><span class="line">        xhr.upload.onprogress = onProgress;</span><br><span class="line">        xhr.open(method, url);</span><br><span class="line">        Object.keys(headers).forEach(key =&gt;</span><br><span class="line">          xhr.setRequestHeader(key, headers[key])</span><br><span class="line">        );</span><br><span class="line">        xhr.send(data);</span><br><span class="line">        xhr.onload = e =&gt; &#123;</span><br><span class="line">         // 将请求成功的 xhr 从列表中删除</span><br><span class="line">         if (requestList) &#123;</span><br><span class="line">           const xhrIndex = requestList.findIndex(item =&gt; item === xhr);</span><br><span class="line">           requestList.splice(xhrIndex, 1);</span><br><span class="line">         &#125;</span><br><span class="line">          resolve(&#123;</span><br><span class="line">            data: e.target.response</span><br><span class="line">          &#125;);</span><br><span class="line">        &#125;;</span><br><span class="line">       // 暴露当前 xhr 给外部</span><br><span class="line">       requestList?.push(xhr);</span><br><span class="line">      &#125;);</span><br><span class="line">    &#125;,</span><br></pre></td></tr></table></figure>
<p>这样在上传切片时传入 requestList 数组作为参数，request 方法就会将所有的 xhr 保存在数组中了</p>
<p>每当一个切片上传成功时，将对应的 xhr 从 requestList 中删除，所以 requestList 中只保存正在上传切片的 xhr</p>
<p>之后新建一个暂停按钮，当点击按钮时，调用保存在 requestList 中 xhr 的 abort 方法，即取消并清空所有正在上传的切片</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">handlePause() &#123;</span><br><span class="line">    this.requestList.forEach(xhr =&gt; xhr?.abort());</span><br><span class="line">    this.requestList = [];</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>
<p>点击暂停按钮可以看到 xhr 都被取消了</p>
<p>恢复上传<br>之前在介绍断点续传的时提到使用第二种服务端存储的方式实现续传</p>
<p>由于当文件切片上传后，服务端会建立一个文件夹存储所有上传的切片，所以每次前端上传前可以调用一个接口，服务端将已上传的切片的切片名返回，前端再跳过这些已经上传切片，这样就实现了“续传”的效果</p>
<p>而这个接口可以和之前秒传的验证接口合并，前端每次上传前发送一个验证的请求，返回两种结果</p>
<p>服务端已存在该文件，不需要再次上传<br>服务端不存在该文件或者已上传部分文件切片，通知前端进行上传，并把已上传的文件切片返回给前端<br>所以我们改造一下之前文件秒传的服务端验证接口</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><span class="line">const extractExt = filename =&gt;</span><br><span class="line">  filename.slice(filename.lastIndexOf(&quot;.&quot;), filename.length); // 提取后缀名</span><br><span class="line">const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录</span><br><span class="line"></span><br><span class="line">const resolvePost = req =&gt;</span><br><span class="line">  new Promise(resolve =&gt; &#123;</span><br><span class="line">    let chunk = &quot;&quot;;</span><br><span class="line">    req.on(&quot;data&quot;, data =&gt; &#123;</span><br><span class="line">      chunk += data;</span><br><span class="line">    &#125;);</span><br><span class="line">    req.on(&quot;end&quot;, () =&gt; &#123;</span><br><span class="line">      resolve(JSON.parse(chunk));</span><br><span class="line">    &#125;);</span><br><span class="line">  &#125;);</span><br><span class="line"></span><br><span class="line"> // 返回已经上传切片名列表</span><br><span class="line">const createUploadedList = async fileHash =&gt;</span><br><span class="line">  fse.existsSync(`$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;`)</span><br><span class="line">   ? await fse.readdir(`$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;`)</span><br><span class="line">   : [];</span><br><span class="line"></span><br><span class="line">server.on(&quot;request&quot;, async (req, res) =&gt; &#123;</span><br><span class="line">  if (req.url === &quot;/verify&quot;) &#123;</span><br><span class="line">    const data = await resolvePost(req);</span><br><span class="line">    const &#123; fileHash, filename &#125; = data;</span><br><span class="line">    const ext = extractExt(filename);</span><br><span class="line">    const filePath = `$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;$&#123;ext&#125;`;</span><br><span class="line">    if (fse.existsSync(filePath)) &#123;</span><br><span class="line">      res.end(</span><br><span class="line">        JSON.stringify(&#123;</span><br><span class="line">          shouldUpload: false</span><br><span class="line">        &#125;)</span><br><span class="line">      );</span><br><span class="line">    &#125; else &#123;</span><br><span class="line">      res.end(</span><br><span class="line">        JSON.stringify(&#123;</span><br><span class="line">          shouldUpload: true，</span><br><span class="line">        uploadedList: await createUploadedList(fileHash)</span><br><span class="line">        &#125;)</span><br><span class="line">      );</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;);</span><br><span class="line">server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));</span><br></pre></td></tr></table></figure>
<p>接着回到前端，前端有两个地方需要调用验证的接口</p>
<p>点击上传时，检查是否需要上传和已上传的切片<br>点击暂停后的恢复上传，返回已上传的切片<br>新增恢复按钮并改造原来上传切片的逻辑</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br></pre></td><td class="code"><pre><span class="line">&lt;template&gt;</span><br><span class="line">  &lt;div id=&quot;app&quot;&gt;</span><br><span class="line">      &lt;input</span><br><span class="line">        type=&quot;file&quot;</span><br><span class="line">        @change=&quot;handleFileChange&quot;</span><br><span class="line">      /&gt;</span><br><span class="line">       &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;</span><br><span class="line">       &lt;el-button @click=&quot;handlePause&quot; v-if=&quot;isPaused&quot;&gt;暂停&lt;/el-button&gt;</span><br><span class="line">     &lt;el-button @click=&quot;handleResume&quot; v-else&gt;恢复&lt;/el-button&gt;</span><br><span class="line">      //...</span><br><span class="line">    &lt;/div&gt;</span><br><span class="line">&lt;/template&gt;</span><br><span class="line"></span><br><span class="line">  async handleResume() &#123;</span><br><span class="line">     const &#123; uploadedList &#125; = await this.verifyUpload(</span><br><span class="line">       this.container.file.name,</span><br><span class="line">       this.container.hash</span><br><span class="line">     );</span><br><span class="line">     await this.uploadChunks(uploadedList);</span><br><span class="line">    &#125;,</span><br><span class="line">    async handleUpload() &#123;</span><br><span class="line">      if (!this.container.file) return;</span><br><span class="line">      const fileChunkList = this.createFileChunk(this.container.file);</span><br><span class="line">      this.container.hash = await this.calculateHash(fileChunkList);</span><br><span class="line"></span><br><span class="line">    const &#123; shouldUpload, uploadedList &#125; = await this.verifyUpload(</span><br><span class="line">        this.container.file.name,</span><br><span class="line">        this.container.hash</span><br><span class="line">      );</span><br><span class="line">      if (!shouldUpload) &#123;</span><br><span class="line">        this.$message.success(&quot;秒传：上传成功&quot;);</span><br><span class="line">        return;</span><br><span class="line">      &#125;</span><br><span class="line"></span><br><span class="line">      this.data = fileChunkList.map((&#123; file &#125;, index) =&gt; (&#123;</span><br><span class="line">        fileHash: this.container.hash,</span><br><span class="line">        index,</span><br><span class="line">        hash: this.container.hash &quot;-&quot; index,</span><br><span class="line">        chunk: file，</span><br><span class="line">        percentage: 0</span><br><span class="line">      &#125;));</span><br><span class="line"></span><br><span class="line">     await this.uploadChunks(uploadedList);</span><br><span class="line">    &#125;,</span><br><span class="line">   // 上传切片，同时过滤已上传的切片</span><br><span class="line">  async uploadChunks(uploadedList = []) &#123;</span><br><span class="line">      const requestList = this.data</span><br><span class="line">       .filter((&#123; hash &#125;) =&gt; !uploadedList.includes(hash))</span><br><span class="line">        .map((&#123; chunk, hash, index &#125;) =&gt; &#123;</span><br><span class="line">          const formData = new FormData();</span><br><span class="line">          formData.append(&quot;chunk&quot;, chunk);</span><br><span class="line">          formData.append(&quot;hash&quot;, hash);</span><br><span class="line">          formData.append(&quot;filename&quot;, this.container.file.name);</span><br><span class="line">          formData.append(&quot;fileHash&quot;, this.container.hash);</span><br><span class="line">          return &#123; formData, index &#125;;</span><br><span class="line">        &#125;)</span><br><span class="line">        .map(async (&#123; formData, index &#125;) =&gt;</span><br><span class="line">          this.request(&#123;</span><br><span class="line">            url: &quot;http://localhost:3000&quot;,</span><br><span class="line">            data: formData,</span><br><span class="line">            onProgress: this.createProgressHandler(this.data[index]),</span><br><span class="line">            requestList: this.requestList</span><br><span class="line">          &#125;)</span><br><span class="line">        );</span><br><span class="line">      await Promise.all(requestList);</span><br><span class="line">      // 之前上传的切片数量 本次上传的切片数量 = 所有切片数量时</span><br><span class="line">      // 合并切片</span><br><span class="line">     if (uploadedList.length requestList.length === this.data.length) &#123;</span><br><span class="line">         await this.mergeRequest();</span><br><span class="line">     &#125;</span><br><span class="line">    &#125;</span><br></pre></td></tr></table></figure>
<p>这里给原来上传切片的函数新增 uploadedList 参数，即上图中服务端返回的切片名列表，通过 filter 过滤掉已上传的切片，并且由于新增了已上传的部分，所以之前合并接口的触发条件做了一些改动</p>
<p>到这里断点续传的功能基本完成了</p>
<p>进度条改进<br>虽然实现了断点续传，但还需要修改一下进度条的显示规则，否则在暂停上传/接收到已上传切片时的进度条会出现偏差</p>
<p>切片进度条<br>由于在点击上传/恢复上传时，会调用验证接口返回已上传的切片，所以需要将已上传切片的进度变成 100%</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">async handleUpload() &#123;</span><br><span class="line">      if (!this.container.file) return;</span><br><span class="line">      const fileChunkList = this.createFileChunk(this.container.file);</span><br><span class="line">      this.container.hash = await this.calculateHash(fileChunkList);</span><br><span class="line">      const &#123; shouldUpload, uploadedList &#125; = await this.verifyUpload(</span><br><span class="line">        this.container.file.name,</span><br><span class="line">        this.container.hash</span><br><span class="line">      );</span><br><span class="line">      if (!shouldUpload) &#123;</span><br><span class="line">        this.$message.success(&quot;秒传：上传成功&quot;);</span><br><span class="line">        return;</span><br><span class="line">      &#125;</span><br><span class="line">      this.data = fileChunkList.map((&#123; file &#125;, index) =&gt; (&#123;</span><br><span class="line">        fileHash: this.container.hash,</span><br><span class="line">        index,</span><br><span class="line">        hash: this.container.hash &quot;-&quot; index,</span><br><span class="line">        chunk: file,</span><br><span class="line">      percentage: uploadedList.includes(index) ? 100 : 0</span><br><span class="line">      &#125;));</span><br><span class="line">      await this.uploadChunks(uploadedList);</span><br><span class="line">    &#125;,</span><br></pre></td></tr></table></figure>
<p>uploadedList 会返回已上传的切片，在遍历所有切片时判断当前切片是否在已上传列表里即可</p>
<p>文件进度条<br>之前说到文件进度条是一个计算属性，根据所有切片的上传进度计算而来，这就遇到了一个问题</p>
<p>点击暂停会取消并清空切片的 xhr 请求，此时如果已经上传了一部分，就会发现文件进度条有倒退的现象</p>
<p>当点击恢复时，由于重新创建了 xhr 导致切片进度清零，所以总进度条就会倒退</p>
<p>此时需要创建一个“假”的进度条，这个假进度条基于文件进度条，但只会停止和增加，然后给用户展示这个假的进度条</p>
<p>这里我们使用 Vue 的监听属性</p>
  <figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line">data: () =&gt; (&#123;</span><br><span class="line">   fakeUploadPercentage: 0</span><br><span class="line">  &#125;),</span><br><span class="line">  computed: &#123;</span><br><span class="line">    uploadPercentage() &#123;</span><br><span class="line">      if (!this.container.file || !this.data.length) return 0;</span><br><span class="line">      const loaded = this.data</span><br><span class="line">        .map(item =&gt; item.size * item.percentage)</span><br><span class="line">        .reduce((acc, cur) =&gt; acc cur);</span><br><span class="line">      return parseInt((loaded / this.container.file.size).toFixed(2));</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,  </span><br><span class="line">  watch: &#123;</span><br><span class="line">   uploadPercentage(now) &#123;</span><br><span class="line">     if (now &gt; this.fakeUploadPercentage) &#123;</span><br><span class="line">       this.fakeUploadPercentage = now;</span><br><span class="line">     &#125;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br></pre></td></tr></table></figure>
<p>当 uploadPercentage 即真的文件进度条增加时，fakeUploadPercentage 也增加，一旦文件进度条后退，假的进度条只需停止即可</p>
<p>至此一个大文件上传 断点续传的解决方案就完成了</p>
<h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><h4 id="大文件上传"><a href="#大文件上传" class="headerlink" title="大文件上传"></a>大文件上传</h4><p>前端上传大文件时使用 Blob.prototype.slice 将文件切片，并发上传多个切片，最后发送一个合并的请求通知服务端合并切片<br>服务端接收切片并存储，收到合并请求后使用 fs.appendFileSync 对多个切片进行合并<br>原生 XMLHttpRequest 的 upload.onprogress 对切片上传进度的监听<br>使用 Vue 计算属性根据每个切片的进度算出整个文件的上传进度</p>
<h4 id="断点续传-1"><a href="#断点续传-1" class="headerlink" title="断点续传"></a>断点续传</h4><p>使用 spart-md5 根据文件内容算出文件 hash<br>通过 hash 可以判断服务端是否已经上传该文件，从而直接提示用户上传成功（秒传）<br>通过 XMLHttpRequest 的 abort 方法暂停切片的上传<br>上传前服务端返回已经上传的切片名，前端跳过这些切片的上传</p>
<h4 id="源代码"><a href="#源代码" class="headerlink" title="源代码"></a>源代码</h4><p>源代码增加了一些按钮的状态，交互更加友好，文章表达比较晦涩的地方可以跳转到源代码查看</p>
<p>file-upload</p>
<p>谢谢观看 :)</p>

        </div>
        <div class="page-footer reveal fs12">
    <hr>
    <div class="sitemap">
        <div class="sitemap-group">
            <span class="fs14">博客</span>
            <a href="/">近期发布</a>
            <a href="/wiki/">分类</a>
            <a href="/notes/">标签</a>
            <a href="/archives/">归档</a>
        </div>
        <div class="sitemap-group">
            <span class="fs14">社交</span>
            <a target="_blank" rel="external nofollow noopener noreferrer" href="">GitHub</a>
            <a target="_blank" rel="external nofollow noopener noreferrer" href="">GitLab</a>
        </div>
        <div class="sitemap-group">
            <span class="fs14">更多</span>
            <a href="/about/">关于本站</a>
            <a href="/bibi/">哔哔</a>
            <a target="_blank" rel="external nofollow noopener noreferrer" href="https://image.jingzhe.xyz">图床</a>
        </div>
    </div>
    <div class="text">
        <p>本博客所有文章除特别声明外，均采用
            <a target="_blank" rel="external nofollow noopener noreferrer"
                href="https://creativecommons.org/licenses/by-nc-sa/4.0/">
                CCBY-NC-SA 4.0
            </a>
            许可协议，转载请注明出处。
        </p>
    </div>
</div>
    </div>
    <aside class="l_right">
        
<header class="header">
    <div class="logo-wrap">
        <a class="avatar" href="/about/">
            <img class="avatar" src="" data-original="https://ae01.alicdn.com/kf/Ua26a9ebfe30847ff8d4d7d7c7647c61cH.jpg"></a>
        <a class="title" href="/">
            <div class="main">惊蛰</div>
            <div class="sub normal cap"></div>
            <div class="sub hover cap" style="opacity:0">jingzhe.xyz</div>
        </a>
    </div>
    <nav class="menu dis-select">
        <a class="nav-item " href="/">博客</a>
        <a class="nav-item " href="/archives/">归档</a>
        <a class="nav-item " href="/about/">关于</a>
    </nav>
</header>
<script>
    $(function () {
        // https://v1.hitokoto.cn/?c=f&encode=text&max_length=8
        $.ajax({
            url: "https://v1.hitokoto.cn/?c=f&encode=text&max_length=10&min_length=5&c=i",
            success: function (result) {
                $(".normal").html(result);
            }
        });

    })
</script>
<div class="widgets">
    <div class="widget-wrap">
    <div class="widget-header cap dis-select"><span class="name">目录</span></div>
    <div class="widget-body fs14 toc-body">
        <ol class="toc"><li class="toc-item toc-level-2"><a class="toc-link" href="#%E5%89%8D%E8%A8%80"><span class="toc-text">前言</span></a></li><li class="toc-item toc-level-2"><a class="toc-link" href="#%E6%95%B4%E4%BD%93%E6%80%9D%E8%B7%AF"><span class="toc-text">整体思路</span></a><ol class="toc-child"><li class="toc-item toc-level-3"><a class="toc-link" href="#%E5%89%8D%E7%AB%AF"><span class="toc-text">前端</span></a></li><li class="toc-item toc-level-3"><a class="toc-link" href="#%E6%9C%8D%E5%8A%A1%E7%AB%AF"><span class="toc-text">服务端</span></a></li><li class="toc-item toc-level-3"><a class="toc-link" href="#%E5%89%8D%E7%AB%AF%E9%83%A8%E5%88%86"><span class="toc-text">前端部分</span></a></li><li class="toc-item toc-level-3"><a class="toc-link" href="#%E6%9C%80%E7%BB%88%E8%A7%86%E5%9B%BE%E5%A6%82%E4%B8%8B"><span class="toc-text">最终视图如下</span></a></li><li class="toc-item toc-level-3"><a class="toc-link" href="#%E6%96%87%E4%BB%B6%E7%A7%92%E4%BC%A0"><span class="toc-text">文件秒传</span></a></li><li class="toc-item toc-level-3"><a class="toc-link" href="#%E6%80%BB%E7%BB%93"><span class="toc-text">总结</span></a></li></ol></li></ol>
    </div>

    <!-- 
     -->
</div>

<script>
    // 让锚点位置下降80个点数
    $(".body-main-content *[id]").each(i => {
        $($(".body-main-content *[id]")[i]).attr('style', 'padding-top: 60px;margin-top: -60px;');
    })

    $(function () {
        $('.toc-link').click(function () {
            if (location.pathname.replace(/^\//, '') == this.pathname.replace(/^\//, '') && location
                .hostname == this.hostname) {
                var $target = $(this);
                $target = $('[id=' + decodeURI(this.hash.slice(1)) + ']');
                if ($target.length) {
                    var targetOffset = $target.offset().top;
                    $('html,body').animate({
                            scrollTop: targetOffset
                        },
                        700);
                    return false;
                }
            }
        });
    })
</script>
    <footer class="footer dis-select">
    <div class="social-wrap">
        <a class="social" title="GitHub" href="https://github.com/xaoxuu" target="_blank"
            rel="external nofollow noopener noreferrer"><img class="lazy entered loaded"
                src="https://cdn.jsdelivr.net/gh/cdn-x/placeholder@1.0.3/social/08a41b181ce68.svg"
                data-src="https://cdn.jsdelivr.net/gh/cdn-x/placeholder@1.0.3/social/08a41b181ce68.svg"
                data-ll-status="loaded"></a><a class="social" title="Spotify"
            href="https://open.spotify.com/user/xaoxuu" target="_blank"
            rel="external nofollow noopener noreferrer"><img class="lazy entered loaded"
                src="https://cdn.jsdelivr.net/gh/cdn-x/placeholder@1.0.3/social/3845874.svg"
                data-src="https://cdn.jsdelivr.net/gh/cdn-x/placeholder@1.0.3/social/3845874.svg"
                data-ll-status="loaded"></a><a class="social" title="Unsplash"
            href="https://unsplash.com/@xaoxuu" target="_blank"
            rel="external nofollow noopener noreferrer"><img class="lazy entered loaded"
                src="https://cdn.jsdelivr.net/gh/cdn-x/placeholder@1.0.3/social/3616429.svg"
                data-src="https://cdn.jsdelivr.net/gh/cdn-x/placeholder@1.0.3/social/3616429.svg"
                data-ll-status="loaded"></a><a class="social" title="Comments" href="/about/#comments"
            rel="noopener noreferrer"><img class="lazy entered loaded"
                src="https://cdn.jsdelivr.net/gh/cdn-x/placeholder@1.0.3/social/942ebbf1a4b91.svg"
                data-src="https://cdn.jsdelivr.net/gh/cdn-x/placeholder@1.0.3/social/942ebbf1a4b91.svg"
                data-ll-status="loaded"></a></div>
</footer>
</div>
    </aside>
</div>

<style>

</style>
    <div class="loading-div" id="loading" style="display: none;">
    <div class="loading">
        <svg id="stage" viewBox="0 0 500 500" preserveAspectRatio="xMidYMid meet" stroke="#fff" stroke-width="3"
            stroke-linecap="round" fill="none"></svg>
        <span>加载中...</span>
    </div>
</div>
<style>
    .loading-div {
        position: absolute;
        display: flex;
        top: 0px;
        width: 100%;
        height: 100%;
        align-items: center;
        z-index: 20;
        animation-name: fadeIn;
        animation-duration: 2s;
        animation-fill-mode: forwards;
    }

    /*动画效果*/
    @keyframes fadeIn {
        from {
            backdrop-filter: blur(0px);
            background: rgba(0, 0, 0, 0);
        }

        to {
            backdrop-filter: blur(2px);
            background: rgb(0, 0, 0, 0.5);
        }
    }

    /*动画效果*/
    @keyframes fadeIn1 {
        from {
            filter: blur(1px);
        }

        to {
            filter: blur(0px);
        }
    }

    .loading {
        color: #fff;
        width: 250px;
        height: 250px;
        margin: 0 auto;
        display: flex;
        align-items: center;
        justify-content: center;
        align-content: center;
        flex-wrap: nowrap;
        flex-direction: column;
        animation-name: fadeIn1;
        animation-duration: 2s;
        animation-fill-mode: forwards;
    }

    svg {
        width: 100%;
        height: 100%;
        margin: 0;
        padding: 0;
        /* background: #000; */
    }
</style>
<script src="https://lib.baomitu.com/gsap/3.7.1/gsap.min.js"></script>
<script>
    let pts = [],
        nPts = 12,
        lineLength = 60,
        timeScale = 0.5

    const radius = 180,
        tl = gsap.timeline()

    for (let i = 0; i < nPts; i++) { // plot points + add circles
        const c = document.createElementNS("http://www.w3.org/2000/svg", "circle"),
            angle = (i / nPts * Math.PI * 2) - Math.PI / 2,
            x = Math.cos(angle) * radius,
            y = Math.sin(angle) * radius

        pts.push(x.toFixed(2) + ',' + y.toFixed(2) + ' ')

        gsap.set(c, {
            x: 250,
            y: 250,
            scale: 0.8,
            attr: {
                class: 'c' + i,
                r: 3,
                cx: x,
                cy: y,
                fill: '#fff',
                stroke: 'none'
            }
        })

        stage.appendChild(c);
    }


    for (let i = 0; i <= nPts; i++) { // add paths + animate
        const p = document.createElementNS("http://www.w3.org/2000/svg", "path")

        gsap.set(p, {
            x: 250,
            y: 250,
            attr: {
                class: 'line' + i,
                d: 'M' + pts[i] + ' C' + gsap.utils.wrap(pts, [i + 2]) + ' ' + gsap.utils.wrap(pts, [i - 5]) +
                    ' ' + gsap.utils.wrap(pts, [i - 2]),
                // d:'M'+pts[i]+' c 0,0 '+gsap.utils.wrap(pts,[i-nPts/2])+' '+gsap.utils.wrap(pts,[i-3]),
                'stroke-dasharray': lineLength + ' ' + lineLength,
                'stroke-dashoffset': lineLength
            },
        });

        stage.appendChild(p);

        const lineTL = gsap.timeline({
                repeat: -1,
                defaults: {
                    duration: 0.4
                }
            })
            .to(p, {
                attr: {
                    'stroke-dashoffset': -lineLength
                },
                ease: 'expo.inOut',
                duration: 0.6
            }, 0.4)

        if (i < nPts) {
            lineTL.to('.c' + i, {
                scale: 1.2,
                ease: 'expo.inOut'
            }, 0.1)
            lineTL.to('.c' + i, {
                scale: 0.8,
                ease: 'expo.in'
            }, 0.5)
        }

        tl.add(lineTL, 1 - i / nPts);
    }

    tl.play(5).timeScale(timeScale)
</script>
<script>
    $(function () {
        // loadingShow()
    })

    function loadingShow() {
        $("#loading").show();
    }

    function loadingHide() {
        $("#loading").hide();
    }
</script>

    <!-- <script src="https://cdn.bootcdn.net/ajax/libs/jquery.scrollbar/0.2.9/jquery.scrollbar.min.js"></script> -->
    <script>
        // $(document).ready(function () {
        //     $('.l_body').scrollbar({
        //         barWidth: 10, //滚动条的宽度(这里根据需要写数值即可，不设置是10,即默认10px)
        //         position: "x,y", //写“x”代表只出水平滚动条，写“y”表示只出垂直滚动条，写“x,y”则出水平和垂直滚动条（只有在内容超出容器时才出现滚动条）
        //         wheelDis: 15 //滚轮滚动一次向下或向上滚动的距离，默认是15，可根据需要修改数值
        //     });
        // });
    </script>
    <script>
        $('img').lazyload({
            threshold: 0,
            failure_limit: 0,
            event: "scroll", //触发事件
            effect: "show", //显示方式
            container: window, //容器
            data_attribute: "original", //属性
            skip_invisible: true,
            appear: null,
            load: null,
            placeholder: ""
        });
    </script>
<script>
            window.imageLazyLoadSetting = {
                isSPA: false,
                preloadRatio: 3,
                processImages: null,
            };
        </script><script>window.addEventListener("load",function(){var t=/\.(gif|jpg|jpeg|tiff|png)$/i,r=/^data:image\/[a-z]+;base64,/;Array.prototype.slice.call(document.querySelectorAll("img[data-original]")).forEach(function(a){var e=a.parentNode;"A"===e.tagName&&(e.href.match(t)||e.href.match(r))&&(e.href=a.dataset.original)})});</script><script>!function(n){n.imageLazyLoadSetting.processImages=o;var e=n.imageLazyLoadSetting.isSPA,i=n.imageLazyLoadSetting.preloadRatio||1,r=Array.prototype.slice.call(document.querySelectorAll("img[data-original]"));function o(){e&&(r=Array.prototype.slice.call(document.querySelectorAll("img[data-original]")));for(var t,a=0;a<r.length;a++)0<=(t=(t=r[a]).getBoundingClientRect()).bottom&&0<=t.left&&t.top<=(n.innerHeight*i||document.documentElement.clientHeight*i)&&function(){var t,e,n,i,o=r[a];t=o,e=function(){r=r.filter(function(t){return o!==t})},n=new Image,i=t.getAttribute("data-original"),n.onload=function(){t.src=i,e&&e()},t.src!==i&&(n.src=i)}()}o(),n.addEventListener("scroll",function(){var t,e;t=o,e=n,clearTimeout(t.tId),t.tId=setTimeout(function(){t.call(e)},500)})}(this);</script></body>